/*
 * FurnitureCatalogTree.java 7 avr. 2006
 *
 * Sweet Home 3D, Copyright (c) 2006 Emmanuel PUYBARET / eTeks <info@eteks.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */
package com.eteks.sweethome3d.swing;

import java.awt.Component;
import java.awt.Container;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.dnd.DnDConstants;
import java.awt.event.AdjustmentListener;
import java.awt.event.MouseEvent;
import java.lang.ref.WeakReference;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import javax.swing.Icon;
import javax.swing.JComponent;
import javax.swing.JEditorPane;
import javax.swing.JScrollPane;
import javax.swing.JToolTip;
import javax.swing.JTree;
import javax.swing.JViewport;
import javax.swing.SwingUtilities;
import javax.swing.ToolTipManager;
import javax.swing.event.AncestorEvent;
import javax.swing.event.AncestorListener;
import javax.swing.event.MouseInputAdapter;
import javax.swing.event.TreeModelEvent;
import javax.swing.event.TreeModelListener;
import javax.swing.event.TreeSelectionEvent;
import javax.swing.event.TreeSelectionListener;
import javax.swing.text.AttributeSet;
import javax.swing.text.Element;
import javax.swing.text.html.HTML;
import javax.swing.text.html.HTMLDocument;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.TreeCellRenderer;
import javax.swing.tree.TreeModel;
import javax.swing.tree.TreePath;

import com.eteks.sweethome3d.model.CatalogPieceOfFurniture;
import com.eteks.sweethome3d.model.CollectionEvent;
import com.eteks.sweethome3d.model.CollectionListener;
import com.eteks.sweethome3d.model.Content;
import com.eteks.sweethome3d.model.FurnitureCatalog;
import com.eteks.sweethome3d.model.FurnitureCategory;
import com.eteks.sweethome3d.model.SelectionEvent;
import com.eteks.sweethome3d.model.SelectionListener;
import com.eteks.sweethome3d.model.UserPreferences;
import com.eteks.sweethome3d.viewcontroller.FurnitureCatalogController;
import com.eteks.sweethome3d.viewcontroller.View;

/**
 * A tree displaying furniture catalog by category.
 * @author Emmanuel Puybaret
 */
public class FurnitureCatalogTree extends JTree implements View
{
	private final UserPreferences preferences;
	private TreeSelectionListener treeSelectionListener;
	private CatalogItemToolTip toolTip;
	
	/**
	 * Creates a tree that displays <code>catalog</code> content.
	 */
	public FurnitureCatalogTree(FurnitureCatalog catalog)
	{
		this(catalog, null);
	}
	
	/**
	 * Creates a tree controlled by <code>controller</code> that displays 
	 * <code>catalog</code> content and its selection.
	 */
	public FurnitureCatalogTree(FurnitureCatalog catalog, FurnitureCatalogController controller)
	{
		this(catalog, null, controller);
	}
	
	/**
	 * Creates a tree controlled by <code>controller</code> that displays 
	 * <code>catalog</code> content and its selection.
	 */
	public FurnitureCatalogTree(FurnitureCatalog catalog, UserPreferences preferences,
			FurnitureCatalogController controller)
	{
		this.preferences = preferences;
		float resolutionScale = SwingTools.getResolutionScale();
		if (resolutionScale != 1)
		{
			// Adapt row height to specified resolution scale
			setRowHeight(Math.round(getRowHeight() * resolutionScale));
		}
		this.toolTip = new CatalogItemToolTip(true, preferences);
		setModel(new CatalogTreeModel(catalog));
		setRootVisible(false);
		setShowsRootHandles(true);
		setCellRenderer(new CatalogCellRenderer());
		addDragListener();
		if (controller != null)
		{
			updateTreeSelectedFurniture(catalog, controller);
			addSelectionListeners(catalog, controller);
			addMouseListeners(controller);
		}
		ToolTipManager.sharedInstance().registerComponent(this);
		addVerticalScrollBarAdjustmentListener();
		// Remove Select all action
		getActionMap().getParent().remove("selectAll");
	}
	
	/**
	 * Adds mouse listeners that will initiate a drag operation 
	 * when the user drags a piece of furniture.
	 */
	private void addDragListener()
	{
		MouseInputAdapter mouseListener = new MouseInputAdapter()
		{
			private boolean canExport;
			
			@Override
			public void mousePressed(MouseEvent ev)
			{
				this.canExport = SwingUtilities.isLeftMouseButton(ev)
						&& getPathForLocation(ev.getX(), ev.getY()) != null;
			}
			
			public void mouseDragged(MouseEvent ev)
			{
				if (this.canExport && getTransferHandler() != null)
				{
					getTransferHandler().exportAsDrag(FurnitureCatalogTree.this, ev, DnDConstants.ACTION_COPY);
				}
				// Don't call exportAsDrag again until mouse is pressed again
				this.canExport = false;
			}
		};
		
		addMouseListener(mouseListener);
		addMouseMotionListener(mouseListener);
	}
	
	/** 
	 * Adds the listeners that manage selection synchronization in this tree. 
	 */
	private void addSelectionListeners(final FurnitureCatalog catalog, final FurnitureCatalogController controller)
	{
		final SelectionListener modelSelectionListener = new SelectionListener()
		{
			public void selectionChanged(SelectionEvent selectionEvent)
			{
				updateTreeSelectedFurniture(catalog, controller);
			}
		};
		this.treeSelectionListener = new TreeSelectionListener()
		{
			public void valueChanged(TreeSelectionEvent ev)
			{
				// Updates selected furniture in catalog from selected nodes in tree. 
				controller.removeSelectionListener(modelSelectionListener);
				controller.setSelectedFurniture(getSelectedFurniture());
				controller.addSelectionListener(modelSelectionListener);
			}
		};
		
		controller.addSelectionListener(modelSelectionListener);
		getSelectionModel().addTreeSelectionListener(this.treeSelectionListener);
	}
	
	/**
	 * Updates selected nodes in tree from <code>catalog</code> selected furniture. 
	 */
	private void updateTreeSelectedFurniture(FurnitureCatalog catalog, FurnitureCatalogController controller)
	{
		if (this.treeSelectionListener != null)
		{
			getSelectionModel().removeTreeSelectionListener(this.treeSelectionListener);
		}
		
		clearSelection();
		for (CatalogPieceOfFurniture piece : controller.getSelectedFurniture())
		{
			TreePath path = new TreePath(new Object[] { catalog, piece.getCategory(), piece });
			addSelectionPath(path);
			scrollRowToVisible(getRowForPath(path));
		}
		
		if (this.treeSelectionListener != null)
		{
			getSelectionModel().addTreeSelectionListener(this.treeSelectionListener);
		}
	}
	
	/**
	 * Returns the selected furniture in tree.
	 */
	private List<CatalogPieceOfFurniture> getSelectedFurniture()
	{
		// Build the list of selected furniture
		List<CatalogPieceOfFurniture> selectedFurniture = new ArrayList<CatalogPieceOfFurniture>();
		TreePath[] selectionPaths = getSelectionPaths();
		if (selectionPaths != null)
		{
			for (TreePath path : selectionPaths)
			{
				// Add to selectedFurniture all the nodes that matches a piece of furniture
				if (path.getPathCount() == 3)
				{
					selectedFurniture.add((CatalogPieceOfFurniture) path.getLastPathComponent());
				}
			}
		}
		return selectedFurniture;
	}
	
	/**
	 * Adds mouse listeners to modify selected furniture and manage links in piece information.
	 */
	private void addMouseListeners(final FurnitureCatalogController controller)
	{
		final Cursor handCursor = new Cursor(Cursor.HAND_CURSOR);
		MouseInputAdapter mouseListener = new MouseInputAdapter()
		{
			@Override
			public void mouseClicked(MouseEvent ev)
			{
				if (SwingUtilities.isLeftMouseButton(ev))
				{
					if (ev.getClickCount() == 2)
					{
						TreePath clickedPath = getPathForLocation(ev.getX(), ev.getY());
						if (clickedPath != null
								&& clickedPath.getLastPathComponent() instanceof CatalogPieceOfFurniture)
						{
							controller.modifySelectedFurniture();
						}
					}
					else if (getCellRenderer() instanceof CatalogCellRenderer)
					{
						URL url = ((CatalogCellRenderer) getCellRenderer()).getURLAt(ev.getPoint(),
								(JTree) ev.getSource());
						if (url != null)
						{
							SwingTools.showDocumentInBrowser(url);
						}
					}
				}
			}
			
			@Override
			public void mouseMoved(MouseEvent ev)
			{
				if (getCellRenderer() instanceof CatalogCellRenderer)
				{
					URL url = ((CatalogCellRenderer) getCellRenderer()).getURLAt(ev.getPoint(), (JTree) ev.getSource());
					if (url != null)
					{
						EventQueue.invokeLater(new Runnable()
						{
							public void run()
							{
								setCursor(handCursor);
							}
						});
					}
				}
				setCursor(Cursor.getDefaultCursor());
			}
		};
		addMouseListener(mouseListener);
		addMouseMotionListener(mouseListener);
	}
	
	/**
	 * Adds adjustment listener to vertical scroll bar if this component is added to a scroll pane.
	 */
	private void addVerticalScrollBarAdjustmentListener()
	{
		addAncestorListener(new AncestorListener()
		{
			private AdjustmentListener adjustmentListener;
			
			public void ancestorAdded(AncestorEvent ev)
			{
				Container parent = getParent();
				if (parent instanceof JViewport)
				{
					JScrollPane scrollPane = (JScrollPane) ((JViewport) parent).getParent();
					this.adjustmentListener = SwingTools
							.createAdjustmentListenerUpdatingScrollPaneViewToolTip(scrollPane);
					scrollPane.getVerticalScrollBar().addAdjustmentListener(this.adjustmentListener);
				}
			}
			
			public void ancestorRemoved(AncestorEvent event)
			{
				if (this.adjustmentListener != null)
				{
					((JScrollPane) ((JViewport) getParent()).getParent()).getVerticalScrollBar()
							.removeAdjustmentListener(this.adjustmentListener);
					this.adjustmentListener = null;
				}
			}
			
			public void ancestorMoved(AncestorEvent event)
			{}
		});
	}
	
	/**
	 * Returns the tool tip displayed by this tree.
	 */
	@Override
	public JToolTip createToolTip()
	{
		if (this.toolTip.isTipTextComplete())
		{
			// Use toolTip object only for its text returned in getToolTipText
			return super.createToolTip();
		}
		else
		{
			this.toolTip.setComponent(this);
			return this.toolTip;
		}
	}
	
	/**
	 * Returns a tooltip for furniture pieces described in this tree.
	 */
	@Override
	public String getToolTipText(MouseEvent ev)
	{
		TreePath path = getPathForLocation(ev.getX(), ev.getY());
		if (this.preferences != null && path != null && path.getPathCount() == 3)
		{
			this.toolTip.setCatalogItem((CatalogPieceOfFurniture) path.getLastPathComponent());
			return this.toolTip.getTipText();
		}
		else
		{
			return null;
		}
	}
	
	/**
	 * Cell renderer for this catalog tree.
	 */
	private class CatalogCellRenderer extends JComponent implements TreeCellRenderer
	{
		private static final int DEFAULT_ICON_HEIGHT = 32;
		private Font defaultFont;
		private Font modifiablePieceFont;
		private DefaultTreeCellRenderer nameLabel;
		private JEditorPane informationPane;
		
		public CatalogCellRenderer()
		{
			setLayout(null);
			this.nameLabel = new DefaultTreeCellRenderer();
			this.informationPane = new JEditorPane("text/html", null);
			this.informationPane.setOpaque(false);
			this.informationPane.setEditable(false);
			add(this.nameLabel);
			add(this.informationPane);
		}
		
		public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded,
				boolean leaf, int row, boolean hasFocus)
		{
			// Configure name label with its icon, background and focus colors 
			this.nameLabel.getTreeCellRendererComponent(tree, value, selected, expanded, leaf, row, hasFocus);
			// Initialize fonts if not done
			if (this.defaultFont == null)
			{
				this.defaultFont = this.nameLabel.getFont();
				String bodyRule = "body { font-family: " + this.defaultFont.getFamily() + "; " + "font-size: "
						+ this.defaultFont.getSize() + "pt; " + "top-margin: 0; }";
				((HTMLDocument) this.informationPane.getDocument()).getStyleSheet().addRule(bodyRule);
				this.modifiablePieceFont = new Font(this.defaultFont.getFontName(), Font.ITALIC,
						this.defaultFont.getSize());
			}
			// If node is a category, change label text
			if (value instanceof FurnitureCategory)
			{
				this.nameLabel.setText(((FurnitureCategory) value).getName());
				this.nameLabel.setFont(this.defaultFont);
				this.informationPane.setVisible(false);
			}
			// Else if node is a piece of furniture, change label text and icon
			else if (value instanceof CatalogPieceOfFurniture)
			{
				CatalogPieceOfFurniture piece = (CatalogPieceOfFurniture) value;
				this.nameLabel.setText(piece.getName());
				this.nameLabel.setIcon(getLabelIcon(tree, piece.getIcon()));
				this.nameLabel.setFont(piece.isModifiable() ? this.modifiablePieceFont : this.defaultFont);
				
				String information = piece.getInformation();
				if (information != null)
				{
					this.informationPane.setText(information);
					this.informationPane.setVisible(true);
				}
				else
				{
					this.informationPane.setVisible(false);
				}
			}
			return this;
		}
		
		@Override
		public void doLayout()
		{
			Dimension namePreferredSize = this.nameLabel.getPreferredSize();
			this.nameLabel.setSize(namePreferredSize);
			if (this.informationPane.isVisible())
			{
				Dimension informationPreferredSize = this.informationPane.getPreferredSize();
				this.informationPane.setBounds(namePreferredSize.width + 2,
						(namePreferredSize.height - informationPreferredSize.height) / 2,
						informationPreferredSize.width, namePreferredSize.height);
			}
		}
		
		@Override
		public Dimension getPreferredSize()
		{
			Dimension preferredSize = this.nameLabel.getPreferredSize();
			if (this.informationPane.isVisible())
			{
				preferredSize.width += 2 + this.informationPane.getPreferredSize().width;
			}
			return preferredSize;
		}
		
		/**
		 * The following methods are overridden for performance reasons.
		 */
		@Override
		public void revalidate()
		{}
		
		@Override
		public void repaint(long tm, int x, int y, int width, int height)
		{}
		
		@Override
		public void repaint(Rectangle r)
		{}
		
		@Override
		public void repaint()
		{}
		
		/**
		 * Returns an Icon instance with the read image scaled at the tree row height or
		 * an empty image if the image couldn't be read.
		 * @param content the content of an image.
		 */
		private Icon getLabelIcon(JTree tree, Content content)
		{
			return IconManager.getInstance().getIcon(content, getRowHeight(tree), tree);
		}
		
		/**
		 * Returns the height of rows in tree.
		 */
		private int getRowHeight(JTree tree)
		{
			return tree.isFixedRowHeight() ? tree.getRowHeight() : DEFAULT_ICON_HEIGHT;
		}
		
		@Override
		protected void paintChildren(Graphics g)
		{
			// Force text anti aliasing on texts
			((Graphics2D) g).setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
					RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
			super.paintChildren(g);
		}
		
		public URL getURLAt(Point point, JTree tree)
		{
			TreePath path = tree.getPathForLocation(point.x, point.y);
			if (path != null && path.getLastPathComponent() instanceof CatalogPieceOfFurniture)
			{
				CatalogPieceOfFurniture piece = (CatalogPieceOfFurniture) path.getLastPathComponent();
				String information = piece.getInformation();
				if (information != null)
				{
					int row = tree.getRowForPath(path);
					getTreeCellRendererComponent(tree, piece, false, false, false, row, false).doLayout();
					Rectangle rowBounds = tree.getRowBounds(row);
					point.x -= rowBounds.x + this.informationPane.getX();
					point.y -= rowBounds.y + this.informationPane.getY();
					if (point.x > 0 && point.y > 0)
					{
						// Search in information pane if point is over a HTML link
						int position = this.informationPane.viewToModel(point);
						if (position > 0)
						{
							HTMLDocument hdoc = (HTMLDocument) this.informationPane.getDocument();
							Element element = hdoc.getCharacterElement(position);
							AttributeSet a = element.getAttributes();
							AttributeSet anchor = (AttributeSet) a.getAttribute(HTML.Tag.A);
							if (anchor != null)
							{
								String href = (String) anchor.getAttribute(HTML.Attribute.HREF);
								if (href != null)
								{
									try
									{
										return new URL(href);
									}
									catch (MalformedURLException ex)
									{
										// Ignore malformed URL
									}
								}
							}
						}
					}
				}
			}
			return null;
		}
	}
	
	/**
	 * Tree model adaptor to Catalog / Category / PieceOfFurniture classes.  
	 */
	private static class CatalogTreeModel implements TreeModel
	{
		private FurnitureCatalog catalog;
		private List<TreeModelListener> listeners;
		
		public CatalogTreeModel(FurnitureCatalog catalog)
		{
			this.catalog = catalog;
			this.listeners = new ArrayList<TreeModelListener>(2);
			catalog.addFurnitureListener(new CatalogFurnitureListener(this));
		}
		
		public Object getRoot()
		{
			return this.catalog;
		}
		
		public Object getChild(Object parent, int index)
		{
			if (parent instanceof FurnitureCatalog)
			{
				return ((FurnitureCatalog) parent).getCategory(index);
			}
			else
			{
				return ((FurnitureCategory) parent).getPieceOfFurniture(index);
			}
		}
		
		public int getChildCount(Object parent)
		{
			if (parent instanceof FurnitureCatalog)
			{
				return ((FurnitureCatalog) parent).getCategoriesCount();
			}
			else if (parent instanceof FurnitureCategory)
			{
				return ((FurnitureCategory) parent).getFurnitureCount();
			}
			else
			{
				// Shouldn't be necessary for other tree items, but javax.swing.plaf.basic.BasicTreeUI$Actions.traverse 
				// might call getChildCount even for tree leaves   
				return 0;
			}
		}
		
		public int getIndexOfChild(Object parent, Object child)
		{
			if (parent instanceof FurnitureCatalog)
			{
				return Collections.binarySearch(((FurnitureCatalog) parent).getCategories(), (FurnitureCategory) child);
			}
			else
			{
				return ((FurnitureCategory) parent).getIndexOfPieceOfFurniture((CatalogPieceOfFurniture) child);
			}
		}
		
		public boolean isLeaf(Object node)
		{
			return node instanceof CatalogPieceOfFurniture;
		}
		
		public void valueForPathChanged(TreePath path, Object newValue)
		{
			// Tree isn't editable
		}
		
		public void addTreeModelListener(TreeModelListener l)
		{
			this.listeners.add(l);
		}
		
		public void removeTreeModelListener(TreeModelListener l)
		{
			this.listeners.remove(l);
		}
		
		private void fireTreeNodesInserted(TreeModelEvent treeModelEvent)
		{
			// Work on a copy of listeners to ensure a listener 
			// can modify safely listeners list
			TreeModelListener[] listeners = this.listeners.toArray(new TreeModelListener[this.listeners.size()]);
			for (TreeModelListener listener : listeners)
			{
				listener.treeNodesInserted(treeModelEvent);
			}
		}
		
		private void fireTreeNodesRemoved(TreeModelEvent treeModelEvent)
		{
			// Work on a copy of listeners to ensure a listener 
			// can modify safely listeners list
			TreeModelListener[] listeners = this.listeners.toArray(new TreeModelListener[this.listeners.size()]);
			for (TreeModelListener listener : listeners)
			{
				listener.treeNodesRemoved(treeModelEvent);
			}
		}
		
		/**
		 * Catalog furniture listener bound to this tree model with a weak reference to avoid
		 * strong link between catalog and this tree.  
		 */
		private static class CatalogFurnitureListener implements CollectionListener<CatalogPieceOfFurniture>
		{
			private WeakReference<CatalogTreeModel> catalogTreeModel;
			
			public CatalogFurnitureListener(CatalogTreeModel catalogTreeModel)
			{
				this.catalogTreeModel = new WeakReference<CatalogTreeModel>(catalogTreeModel);
			}
			
			public void collectionChanged(CollectionEvent<CatalogPieceOfFurniture> ev)
			{
				// If catalog tree model was garbage collected, remove this listener from catalog
				CatalogTreeModel catalogTreeModel = this.catalogTreeModel.get();
				FurnitureCatalog catalog = (FurnitureCatalog) ev.getSource();
				if (catalogTreeModel == null)
				{
					catalog.removeFurnitureListener(this);
				}
				else
				{
					CatalogPieceOfFurniture piece = ev.getItem();
					switch (ev.getType())
					{
						case ADD:
							if (piece.getCategory().getFurnitureCount() == 1)
							{
								// Fire nodes inserted for new category
								catalogTreeModel.fireTreeNodesInserted(new TreeModelEvent(catalogTreeModel,
										new Object[] { catalog }, new int[] { Collections
												.binarySearch(catalog.getCategories(), piece.getCategory()) },
										new Object[] { piece.getCategory() }));
							}
							else
							{
								// Fire nodes inserted for new piece
								catalogTreeModel.fireTreeNodesInserted(new TreeModelEvent(catalogTreeModel,
										new Object[] { catalog, piece.getCategory() }, new int[] { ev.getIndex() },
										new Object[] { piece }));
							}
							break;
						case DELETE:
							if (piece.getCategory().getFurnitureCount() == 0)
							{
								// Fire nodes removed for deleted category
								catalogTreeModel.fireTreeNodesRemoved(
										new TreeModelEvent(catalogTreeModel, new Object[] { catalog },
												new int[] { -(Collections.binarySearch(catalog.getCategories(),
														piece.getCategory()) + 1) },
												new Object[] { piece.getCategory() }));
							}
							else
							{
								// Fire nodes removed for deleted piece
								catalogTreeModel.fireTreeNodesRemoved(new TreeModelEvent(catalogTreeModel,
										new Object[] { catalog, piece.getCategory() }, new int[] { ev.getIndex() },
										new Object[] { piece }));
							}
							break;
					}
				}
			}
		}
	}
}
